The new ECMAScript (ES) modules are not fully compatible with previous language versions, so the JavaScript engine used needs to know for each file whether it is “old” JavaScript code or a “new” module. For example, the strict mode preferred by many programmers introduced in ECMAScript 5 was once optional and had to be explicitly enabled, while it is always active in ES modules. Thus, the following snippet can be syntactically interpreted as both conventional JavaScript code and as an ES module:
a = 5; |
As a classical Node.js module, this is equivalent to global.a = 5, because the variable a was not declared and strict mode was not explicitly activated, therefore a is treated as a global variable. If you try to load the same file as an ES module, you get the error “ReferenceError: a is not defined” because undeclared variables may not be used in strict mode.
Browsers solve the problem of distinction with an extension of the <script> tag: scripts without the type attribute or with the type=”text/javascript” attribute are still run in legacy mode, while scripts which use the type = “module” attribute are processed as modules. Thanks to this simple separation, all popular browsers now support the new modules. Implementation in Node.js proves to be considerably more difficult: the framework for JavaScript applications invented in 2009 used the CommonJS standard for modules, which was based on the require function. This feature can be used at any time to load another module on the basis of its path relative to the currently running module. The new ES modules are also being defined by their path, but how would Node.js know if the module being loaded is a legacy CommonJS or an ES module? A distinction based on the syntax is not enough, since even ES modules that do not use new keywords are not compatible with CommonJS modules.
In addition, ECMAScript 6 also provides that modules can be loaded from a URL, while CommonJS is limited to relative and absolute file paths. This innovation not only makes loading more complicated, but also potentially slow, because URLs do not need to point to local files. Especially in the browser, scripts and modules are usually loaded via the HTTP network protocol.
CommonJS allows modules to be loaded via the require function that returns the loaded module. For example, a CommonJS module might start as follows:
const { readFile } = require(‘fs’);
const myModule = require(‘./my-module’);
This is not an option in ECMAScript 6, because the load times of modules over HTTP during a require() call would block the entire execution for a disproportionately long period of time. Instead, ES modules provide two ways of loading other modules. In most cases, it makes sense to use import:
import { readFile } from ‘fs’;
import myModule from ‘./my-module’;
However, this inevitably delays the execution of the module until fs and ./my-module have been loaded, but they do not block the execution of other modules. It gets more complicated when modules have to be dynamically loaded. What seems trivial in CommonJS modules becomes more difficult asynchronously:
if (condition) {
myOtherModule = require(‘./my-other-module’);
}
ECMAScript would like to address this issue through a functional use of the import keyword, which loads the modules asynchronously and returns a Promise object with each call. One drawback is that the programmer is now also responsible for error handling, because the error is not automatically passed to the caller as in the synchronous case.
if (condition) {
import(‘./my-other-module.js’)
.then(myOtherModule => {
// Module was loaded successfully and can
// now be used here.
})
.catch(err => {
// An error occurred that needs to be handled here.
console.error(err);
});
}
If the function in which the module is to be loaded has been declared using the async keyword, the use of import() is clearer thanks to the await function introduced in ECMAScript 6, and the error handling is passed to the caller as in synchronous execution:
if (condition) {
myOtherModule = await import(‘./my-other-module’);
}
The usage of import as a function is not a component of ECMAScript 6, but a so-called Stage 3 Proposal and will probably be standardized in one of the next JavaScript versions. In addition, it is already supported in many browsers like Firefox, Chrome and Safari as well as in Node.js.
Use in Node.js
The difficulty of distinguishing CommonJS and ES modules has led to the introduction of a new filename extension for ES modules under Node.js: Files with names ending in .mjs can already be loaded by Node.js as ES modules, if the –experimental-modules option has been set. As of Node.js 8.5.0 released in September 2017, if you save the following code as testmodule.mjs, you can execute it using the node –experimental-modules testmodule.mjs command:
export function helloWorld(name) {
console.log(`Hallo, ${name}!`);
}
helloWorld(‘javascript-conference.com’);
Node.js 12 extends the support for ES modules. What’s important is that now the package.json file, which is a part of each package and contains information such as the unique name of the package, can be used. The JSON format used has now been extended with a new property called type. This can be optionally changed to commonjs or module to determine the mode in which the JavaScript files contained in the package should be loaded by default. The following configuration specifies a package example-package that at the least must contain the ES module index.js:
{
“name”: “example-package”,
“type”: “module”,
“main”: “index.js”
}
As usual, the “main” field specifies which file should serve as the entry point; the index.js module could for example look like this:
import { userInfo } from ‘os’;
export function greet() {
return `Hello ${userInfo().username}!`;
}
This module can now be loaded from other files. Packages are usually located in their own folders in the node_modules directory. To load the package just created, we use the following directory structure and a new file called main.js:
– main.js
+ node_modules
+ example-package
– package.json
– index.js
The main.js file can either refer to a legacy CommonJS or a new ECMAScript module. In both cases, the example-package cannot be loaded using the usual require() call because ECMAScript modules must always be loaded asynchronously. As a result, CommonJS modules must load ES modules using import which is available there as well:
import(‘example-package’)
.then(package => {
console.log(package.greet());
})
.catch(err => {
console.error(err);
});
This has the disadvantage that CommonJS modules cannot access other modules or packages right at the beginning as usual, but can do so only after the fact and asynchronously. The script is executed as described above: node –experimental-modules main.js. It is even easier if the entry point itself is also an ES module. If you rename main.js to main.mjs , import can be used:
import { greet } from ‘example-package’;
console.log(greet());
It is thus possible to use both CommonJS and ECMAScript modules in one application, yet it is associated with complications. CommonJS modules need to know if the module being loaded is a CommonJS or ES module and can load ES modules only asynchronously. This also applies to the loading of packages that have been installed via npm, for example. Built-in modules such as fs and crypto can be loaded in both ways.
Differences in Node.js
Apart from the problem of asynchronous loading of dependencies, there are further differences between the previous and new modules in Node.js to take into consideration. In particular, Node.js specific features like the variables __dirname, __filename, export and module are no longer available in ES modules. __dirname and __filename can be reconstructed from the new import.meta object if needed:
import { fileURLToPath } from ‘url’;
import { dirname} from ‘path’;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
The variables module and exports have been eliminated without replacement; the same applies for properties such as module.filename, module.id and module.parent. Likewise, require() and require.main are no longer available.
While circular dependencies in CommonJS have been resolved through the caching of module.exports objects of the respective modules, ECMAScript 6 uses so-called bindings. Simply put, ES modules do not export and import values, just references to values. Modules that import such a reference can access the value, yet cannot change it. The module that has exported the reference may assign a new value to the reference that will be used by other modules that have imported the reference from that point on. This makes an essential difference when compared to the previous concept, which allowed the assignment of properties to the module.exports object of a CommonJS module at any point in time, whereby these changes were only partially reflected in other modules.
In accordance with ECMAScript specification, import does not complete the file path by default with the file name extension, as Node.js previously had done for CommonJS modules, therefore this must be explicitly stated. Likewise, the behavior changes when the specified path is a directory: import ‘./directory’ does not look for an index.js file in the specified folder, as was the standard case in Node.js, but rather throws an error. Both can be changed by passing the experimental option –es-module-specifier-resolution =node .
Conclusion
In the recently released Node.js 12.1.0 version, the use of ECMAScript modules still needs to be explicitly activated through the –experimental-modules option, because it is an experimental feature. However, the goal of the developers is to finalize this functionality and support ES modules without explicit activation before Node.js 12 becomes the new long-term support version, which is expected to happen in October 2019.
The switch from CommonJS to ECMAScript modules is complicated by the variety of existing CommonJS modules. Individual packages cannot switch to ES modules without risking incompatibility with existing applications and packages that load the respective packages using require(). Tools like Babel, which translate the newer syntax into code compatible with older environments, can make the transition easier. Newer frameworks like Deno turn their back on the diverse modular systems of recent years and rely exclusively on ECMAScript modules. For the development of JavaScript as a programming language, the introduction of standardized modules is an important step and paves the way for future improvements.